2.2 结构体与方法集的实现

结构体是我们在实际运用中使用比较多的一个概念,Go语言封装的比较简单,我们在使用的时候不需要关注太多的东西。

但是如果对于性能有要求、需要开发框架时,我们还是需要对结构体进行一个深入的了解。

本节我们将针对结构体的内存布局、接口实现及面向对象编程等进行讲解。

本节代码存放目录为 lesson2

结构体的内存布局

在上一章中我们讲过了基础类型的内存表示方式,所有定义最终在内存中都会以二进制的形式存储。

在结构体中,其实也是按照这样的方式,只不过是按照每个字段排列的方式进行。

我们以实际的案例进行讲解,结构体代码如下:

type CacheExample struct {
    a int8
    b int32
    c int16
    d int16
}

在结构体中,每个字段所占用的位数是按照其类型制定的,如下所示:

type CacheExample struct {
    a int8  // 占用8位,1字节
    b int32 // 占用32位,4字节
    c int16 // 占用16位,2字节
    d int16 // 占用16位,2字节
}

从上面的结构体中,我们可以计算出一共占用了9字节,直观的来看在内存中可以理解为就是这样:

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

其中0存储了a字段,1、2、3、4存储了b字段,5、6存储了c字段,7、8存储了d字段。

那么现实中是否是这样的呢?Go语言中不是这样的,如果是上面的结构体,在内存中实际是这样的:

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |

存储字段如下所示:

0 ~ 3 地址:存储 a 字段
4 ~ 7 地址:存储 b 字段
8 ~ 9 地址:存储 c 字段
10 ~ 11 地址:存储 d 字段

那么为什么会是这样呢?这是由于 Go 语言中内存对齐的概念。

内存对齐是指:字段存储的位置应该是类型字节数的倍数。

直观的来说,就是比如b字段int324字节,那么这个字段存储的起始位置就只能是4的倍数,比如说:0、4、8、12...

也就是说b在上面的例子中,由于0已经被a占用,b字段只能从4开始。那么ab之间剩下的空位应该怎么办呢?

这就涉及到了另一个概念,也就是内存填充,也就是说中间空的部分都会填充上默认值。

那么我们在举一个例子:

type CacheExample1 struct {
    a int8  // 占用8位,1字节,内存实际占用4字节
    b int32 // 占用32位,4字节,内存实际占用4字节
    c int8  // 占用8位,1字节,内存实际占用2字节
    d int16 // 占用16位,2字节,内存实际占用2字节
}

在上面的例子中,基于内存对齐与内存填充,最终得出的占用字节数就是:12

a字段:int8,占用 1 字节。由于 b 需要 4 字节对齐,所以 a 后面填充了 3 个字节,使得 b 可以从地址 4 开始。因此,a 实际占用了4字节。

b字段:int32,占用 4 字节,实际也占用 4 字节。

c字段:int8,占用 1 字节。为了对齐下一个 d 字段(int16,2 字节对齐),在 c 之后填充了 1 个字节,使得 d 从一个偶数地址开始。

d字段:int16,占用 2 字节,实际占用 2 字节。

我们可以通过代码直接输出结构体所占用的字节数,代码如下所示:

var (
        example  CacheExample
        example1 CacheExample1
    )
    fmt.Printf("结构体 CacheExample 占用的字节数: %d\n", unsafe.Sizeof(example))
    fmt.Printf("结构体 CacheExample1 占用的字节数: %d\n", unsafe.Sizeof(example1))

结果输出如下所示:

结构体 CacheExample 占用的字节数: 12
结构体 CacheExample1 占用的字节数: 12

那么为什么要这样做呢?我们先假设没有补齐,就是一个字段挨着一个字段布局的,如下所示:

地址 0 1 2 3 4 5 6 7
内容 a b b b b ... ... ...

在上面的示意中,由于b是需要占用4位的,所以占用了:1、2、3、4

这里我们需要补充一个知识,那就是CPU在读写内存数据时,都是按照4位(32位系统)或者8位(64位系统)读写的。

比如CPU读取的是:0 ~ 3、4 ~ 7,那么我们现在再回头看,使用连续存储是不是就并不适用了呢?

也就是说,如果我按照连续存储,CPU首先读取0 ~ 3拿到第1、2、3位置的b字段数据,还要再读取一次4 ~ 7拿到第4位置的b字段数据,之后拼接在一起才会得到实际的b字段数据。

这种显然是不划算的,所以就出现了补位对齐,那么CPU在读取的时候直接读取4 ~7就一次拿到了b字段的数据。

经过上面的学习,我们现在已经知道了基本的概念,也就是说结构体占用是与字段排序有关系的。我们看下面的例子:

type CacheExample2 struct {
    a int8  // 占用8位,1字节,内存实际占用1字节
    c int8  // 占用8位,1字节,内存实际占用1字节
    d int16 // 占用16位,2字节,内存实际占用2字节
    b int32 // 占用32位,4字节,内存实际占用4字节
}

fmt.Printf("结构体 CacheExample2 占用的字节数: %d\n", unsafe.Sizeof(example2))

结果输出如下所示:

结构体 CacheExample2 占用的字节数: 8

看到上面的输出,我们可能会有点奇怪,为什么d占用不是4呢?

我们来分析一下,首先ac占用了0、1位置,接下来的d字段类型是2字节,d直接存储到2、3

最后的b字段类型需要4字节,而现在刚好排到了4,所以b字段直接从4开始,占用4、5、6、7

综上所述,如果需要优化结构体的内存占用,那么我们只需要:将占用小的字段放在前面。

字段访问及方法调用

字段是如何访问的?

在上面我们讲过了结构体的内存布局,也就是一块连续的存储空间,结构体的字段按照定义时候的顺序排列在内存中。

那么在Go语言中,访问的时候其实也比较简单,就是通过字段偏移量、字段占用位数从内存中取出字段。

比如说:a字段的存储地址偏移量是 0,占用字节数是 4,那么当访问的时候就会从内存的0 ~ 3去取出。

那么这个偏移量又是基于谁的偏移呢?在Go语言中,就是相对于结构体内存地址初始位置的偏移。

那么结构体内存初始位置又是如何确定的呢?这是在初始化时,系统会为该结构体变量分配一个内存地址,这个内存地址的初始位置就是结构体的零点。

我们可以通过代码输出字段的偏移量,如下所示:

type MethodExample struct {
    Score int16
    Age   int16
}

fmt.Println("Offset of age:", unsafe.Offsetof(MethodExample{}.Age))

结果输出如下所示:

Offset of age: 2

也就是说Age字段的偏移量是2,那么我们核实一下。针对上面的结构体,Score占用2字节,那么就是内存中的0、1,同时Score也不需要进行补位,所以Age自然就是从2开始,偏移量也就是2

在上面的示例中,在实际读取Age字段时,也就是从结构体初始位置读取:2 ~ 3的数据。

比如结构体初始位置是30,那么在取Age字段的时候,就是取内存地址的:32 ~ 33


那么Go语言又是怎么知道该访问哪些内存地址的呢

在上面我们讲解了访问计算方法,同时在前面的章节我们讲过机器指令的概念,就是说在编译时会将代码编译为机器能够识别的机器指令。

同样的,对于结构体的访问也是这样的。

在编译的时候,编译器就会将结构体各个字段的偏移量、位数等计算好并且形成机器码,那么在执行的时候就可以直接使用这些机器码进行读取操作。

也就是说其实在编译的时候就会将这些信息计算好形成机器码,并且内嵌到了最终的指令集中。


结构体方法是如何访问的?

结构体方法是我们比较常用的东西,基本上在实际开发中是离不开结构体方法的。

如下代码所示:

type MethodExample struct {
    Score int16
    Age   int16
}

func (m *MethodExample) Print() {
    fmt.Printf("MethodExample score is-> %d\n", m.Score)
}

func (m *MethodExample) Set(score int16) {
    fmt.Printf("MethodExample set, score-> %s\n", score)
    m.Score = score
}

结构体方法与函数有什么区别?

Go语言中,类似于上面代码中的方法,它们有一个接收者,也就是:(m * MethodExample),这种有接收者的就叫做方法,而普通函数是没有接收者的。

在上面的写法中,我们使用的接收者是指针,这意味着方法接收的是一个指针,也就是引用接收者。同时还有另一种写法,如下所示:

type MethodExample1 struct {
    Score int16
    Age   int16
}

func (m MethodExample1) Print() {
    fmt.Printf("MethodExample score is-> %d\n", m.Score)
}

func (m MethodExample1) Set(score int16) {
    fmt.Printf("MethodExample set, score-> %d\n", score)
    m.Score = score
}

我们可以这样调用:

me := &MethodExample{}
me.Set(12)
me.Print()

var (
    me1 MethodExample1
)
me1.Set(12)
me1.Print()

执行代码输出如下:

MethodExample set, score-> 12
MethodExample score is-> 12
MethodExample1 set, score-> 12
MethodExample1 score is-> 0

那么为什么第二个输出的Score会是0呢?

这就涉及到了Go语言方法的底层概念:指针接收者传递的是结构体的指针地址,而值接收者传递的是结构体的一个副本。

也就是说,当我们的接收者为指针时,由于指针指向的是内存地址,所以在调用Set的时候,操作的就是me本身。

当我们的接收者为普通值引用时,在调用Set的时候,其实是复制了一份me1传递过去,那么这时候操作字段赋值,自然对me1是不生效的,因为操作的就不是me1

所以我们可以进一步的发散。方法是不是本身与函数就是一样的,在调用的时候只不过是把前面的接收者(结构体)作为了一个参数传递过去呢?

答案是肯定的,在底层其实就是这么操作的,本身方法与函数其实是一样的,只不过方法会将接收者作为参数隐式的给传递过去。


结构体方法是怎么被调用到的?

在上面我们讲解过了字段是怎么被访问的,那么我们在调用方法时,也是使用了符号.就进行了直接调用,那么它的底层又是怎么样的呢?

这里涉及到一个概念,叫做:方法表。在Go语言中,会为我们的结构体类型创建一个方法表,当我们在调用时,就会去方法表里面找到我们的方法信息,之后再去调用实际的函数。

结构示意如下所示:

+----------------------+
|  MethodExample       |  <--- 定义的结构体类型
+----------------------+
|  Method Table (MT)    |  <--- 类型T的方法表(与类型关联,不与实例关联)
+----------------------+
|  Method 1:            |
|  - Name: "Print"    |  --- 方法名称
|  - Signature: ()      |  --- 方法签名(参数和返回值类型)
|  - Function Ptr:      |  --- 指向方法实现的函数指针
|                      |
|  Method 2:            |
|  - Name: "Set"    |
|  - Signature: (int16)   |
|  - Function Ptr:      |
|                      |
|  ...                  |
+----------------------+

在实际的应用中,当我们创建了一个结构体后,就会形成一个方法表,这个方法表是与类型关联的,比如与MethodExample这个类型关联。

在这个方法表中,标识了对应的结构体类型、方法的列表,在方法信息中包含了方法名称、方法签名以及指向实际函数的指针。

总之可以大概理解为,当我们定义结构体类型时,这个类型就会附带上一个表,这个表里面存储了这个结构体所有的方法信息。

需要注意是的,这个方法表是与类型本身MethodExample关联的,而不是与创建的实例me关联的。

在上面我们讲到了结构体类型与方法表的关系及结构,那么在我们实际使用的时候,又是怎么去调用到的呢?

我们可以通过下面的结构来探索:

+----------------------+
|  me of MethodExample |  <--- 结构体实例 me
+----------------------+
|  score: int16        |  <--- 结构体字段 score
|  age: int6           |  <--- 结构体字段 age
|  ...                 |
+----------------------+

调用 `me.Print()` 时:

1. Go 运行时系统知道 me 是 MethodExample 类型。

2. Go 通过 MethodExample 的类型信息找到 MethodExample 的方法表(MT)。

3. 在方法表中找到 Print 的函数指针。

4. 使用该函数指针调用 Print 的实现。

从上面的示意我们可以看出,在执行调用时,运行时系统会通过me找到他的实际类型,也就是MethodExample,之后再次通过类型找到了所关联的方法表MT,下一步就是使用指针直接去调用实际的 Print 函数。

需要注意的是:方法表与前面所讲到的一样,都是由编译器在编译时就已经生成的了,在使用的时候直接进行查找即可。

小结

本节我们讲解了结构体的内存布局、字段访问、方法与函数的区别以及方法调用。

关于本节总结如下:

  • 结构体字段按照定义的顺序排列存储

  • 字段存储的位置应该是类型字节数的倍数

  • 字段内存排列时需要补位对齐

  • 将占用位数小的字段放在前面可以减少内存占用

  • 结构体在初始化时得到了初始内存地址

  • 通过字段偏移量、字节数访问内存地址得到字段

  • 结构体方法本质上与函数是一样的

  • 结构体类型关联一个方法表,方法表记录了结构体实现的所有方法信息

  • 调用时通过类型关联查找得到最终的函数进行调用

results matching ""

    No results matching ""